/* eslint-disable no-param-reassign */
/* eslint-disable react/prefer-stateless-function */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable max-classes-per-file */
/* eslint-disable max-len */
/**
 * Synched capability
 * Python-controlled component Higher-Order Component
 * Allows components to sync their state with Python backend
 * @module Widgets/Widget
 * @author Adrian Quintana (adrian@metacell.us)
 * @author Matteo Cantarelli (matteo@metacell.us)
 */
import React, { Component, ComponentType } from 'react';
import { execPythonMessage, evalPythonMessage } from './GeppettoJupyterUtils';

// Window interface for Geppetto is declared in index.tsx

interface PythonControlledProps {
  model?: string;
  id?: string;
  value?: any;
  default?: any;
  searchText?: string;
  checked?: boolean;
  handleChange?: (event: any, index?: any, value?: any) => void;
  postHandleChange?: (newValue: any, oldValue: any) => void;
  validate?: (value: any) => Promise<{ errorMsg?: string }>;
  prePythonSyncProcessing?: (value: any) => any;
  callback?: (newValue: any, oldValue: any) => void;
  realType?: string;
  method?: string;
  pythonParams?: any[];
  postProcessItems?: (pythonData: any[], value: any) => React.ReactNode;
  [key: string]: any;
}

interface PythonControlledState {
  model?: string;
  componentType?: string;
  value?: any;
  searchText?: string;
  checked?: boolean;
  errorMsg?: string;
  errorState?: string;
  pythonData?: any[];
}

/**
 * Gets the component name from a wrapped component
 */
function getNameFromWrappedComponent(WrappedComponent: any): string {
  return (
    WrappedComponent.name ||
    WrappedComponent.displayName ||
    WrappedComponent?.Naked?.render?.name ||
    WrappedComponent?.render?.name ||
    'Unknown'
  );
}

/**
 * Removes invalid characters from value to enable querying of selectors.
 * Due to close integration with Python commands, characters []'". can be part of an id attribute.
 */
function cleanAttributeValue(value: string): string {
  return value.replace(/[[\]'.]+/g, '');
}

/**
 * Python Controlled Capability
 * Provides HOC functions for creating Python-synced components
 */
const PythonControlledCapability = {
  /**
   * Creates a Python-controlled component (basic HOC)
   */
  createPythonControlledComponent<P extends object>(
    WrappedComponent: ComponentType<P> | any
  ): any {
    // Fixes components defined as objects (e.g. Material-ui components)
    let ComponentToWrap = WrappedComponent;
    if (typeof WrappedComponent !== 'function') {
      class Wrapper extends Component<P & PythonControlledProps> {
        render() {
          return <WrappedComponent {...(this.props as any)} />;
        }
      }
      ComponentToWrap = Wrapper;
    }

    class PythonControlledComponent extends (ComponentToWrap as any) {
      id: string;
      _isMounted: boolean = false;
      syncValueWithPython?: (value: any) => void;

      constructor(props: P & PythonControlledProps) {
        super(props);
        if (this.state === undefined) {
          this.state = {};
        }
        (this.state as PythonControlledState).model = props.model;
        (this.state as PythonControlledState).componentType = getNameFromWrappedComponent(WrappedComponent);
        this.id = props.id === undefined ? props.model || '' : props.id;
        this._isMounted = false;
      }

      setSyncValueWithPythonHandler(handler: (value: any) => void): void {
        this.syncValueWithPython = handler;
      }

      connectToPython(componentType: string, model: string): void {
        const id = this.id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
        const escapedModel = model.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
        execPythonMessage(
          `jupyter_geppetto.ComponentSync(componentType="${componentType}",model="${escapedModel}",id="${id}").connect()`
        );
      }

      disconnectFromPython(): void {
        const id = this.id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
        execPythonMessage(
          `jupyter_geppetto.remove_component_sync(componentType="${(this.state as PythonControlledState).componentType}",model="${id}")`
        );
        if (window.GEPPETTO?.ComponentFactory) {
          window.GEPPETTO.ComponentFactory.removeExistingComponent(
            (this.state as PythonControlledState).componentType || '',
            this
          );
        }
      }

      componentWillUnmount(): void {
        this._isMounted = false;
        this.disconnectFromPython();
        if (super.componentWillUnmount) {
          super.componentWillUnmount();
        }
      }

      componentDidMount(): void {
        this._isMounted = true;
        if (window.GEPPETTO?.ComponentFactory) {
          window.GEPPETTO.ComponentFactory.addExistingComponent(
            (this.state as PythonControlledState).componentType || '',
            this,
            true
          );
        }
        if (this.props.model !== undefined) {
          this.connectToPython(
            (this.state as PythonControlledState).componentType || '',
            this.props.model
          );
        }
        if (this.props.value !== undefined) {
          this.setState({ value: this.props.value });
        }
        if (super.componentDidMount) {
          super.componentDidMount();
        }
      }
    }

    return PythonControlledComponent;
  },

  /**
   * Creates a Python-controlled control component (with form controls)
   */
  createPythonControlledControl<P extends object>(
    WrappedComponent: ComponentType<P> | any
  ): any {
    const PythonControlledComponent = this.createPythonControlledComponent(WrappedComponent);

    class PythonControlledControl extends PythonControlledComponent {
      oldValue?: any;
      updateTimer?: NodeJS.Timeout;

      constructor(props: P & PythonControlledProps) {
        super(props);
        this.state = {
          ...this.state,
          value: '',
          searchText: '',
          checked: false,
        };

        // If a handleChange method is passed as a props it will overwrite the handleChange python controlled capability
        this.handleChange =
          this.props.handleChange === undefined
            ? this.handleChange.bind(this)
            : this.props.handleChange.bind(this);
        this.handleUpdateInput = this.handleUpdateInput.bind(this);
        this.handleUpdateCheckbox = this.handleUpdateCheckbox.bind(this);
      }

      componentDidMount(): void {
        super.componentDidMount();
        this.UNRELIABLE_SyncDefaultValueWithPython();
      }

      /**
       * since we don't know when a component will be synched with python,
       * we can't know when to check if this.state.value should be replaced
       * with this.props.default
       */
      UNRELIABLE_SyncDefaultValueWithPython(timeInterval = 600, attempts = 0): void {
        // Implementation can be added if needed
        // Currently commented out in original code
      }

      UNSAFE_componentWillReceiveProps(nextProps: P & PythonControlledProps): void {
        const matchingProps = JSON.stringify(this.props) === JSON.stringify(nextProps);
        if (!matchingProps) {
          this.disconnectFromPython();
          this.id = nextProps.id === undefined ? nextProps.model || '' : nextProps.id;

          if (window.GEPPETTO?.ComponentFactory) {
            window.GEPPETTO.ComponentFactory.addExistingComponent(
              (this.state as PythonControlledState).componentType || '',
              this
            );
          }
          if (nextProps.model) {
            this.connectToPython(
              (this.state as PythonControlledState).componentType || '',
              nextProps.model
            );
          }

          if (nextProps.searchText !== undefined && (this.state as any).searchText !== nextProps.searchText) {
            this.setState({ searchText: nextProps.searchText });
          }
          if (nextProps.checked !== undefined && (this.state as any).checked !== nextProps.checked) {
            this.setState({ checked: nextProps.checked });
          }
          if (nextProps.value !== undefined && (this.state as any).value !== nextProps.value) {
            this.setState({ value: nextProps.value });
          }
          if (nextProps.model !== undefined && (this.state as PythonControlledState).model !== nextProps.model) {
            this.setState({ model: nextProps.model });
          }
        }
        if (super.UNSAFE_componentWillReceiveProps) {
          super.UNSAFE_componentWillReceiveProps(nextProps);
        }
      }

      componentDidUpdate(prevProps: P & PythonControlledProps, prevState: PythonControlledState): void {
        const componentName = getNameFromWrappedComponent(WrappedComponent);
        const currentState = this.state as PythonControlledState;

        switch (componentName) {
          case 'AutoComplete':
            if (currentState.searchText !== prevState.searchText && this.props.onChange) {
              (this.props.onChange as any)(currentState.searchText);
            }
            break;
          case 'Checkbox':
            if (currentState.checked !== prevState.checked && this.props.onChange) {
              (this.props.onChange as any)(null, currentState.checked);
            }
            break;
          default:
            if (currentState.value !== prevState.value && this.props.onChange) {
              (this.props.onChange as any)(null, null, currentState.value);
            }
            break;
        }

        if (this.props.validate) {
          this.props.validate(currentState.value || '').then((response) => {
            if (currentState.errorState !== response.errorMsg) {
              this.setState({ errorState: response.errorMsg });
            }
          });
        }

        if (
          this.props.model === prevProps.model &&
          currentState.value === '' &&
          this.props.default
        ) {
          this.UNRELIABLE_SyncDefaultValueWithPython(1000);
        }

        if (super.componentDidUpdate) {
          super.componentDidUpdate(prevProps, prevState);
        }
      }

      updatePythonValue(newValue: any): void {
        let processedValue = newValue;
        if (this.props.prePythonSyncProcessing !== undefined) {
          processedValue = this.props.prePythonSyncProcessing(newValue);
        }

        // whenever we invoke syncValueWithPython we will propagate
        // the Javascript value of the model to Python
        if (this.syncValueWithPython) {
          switch (this.props.realType) {
            case 'float':
              if (!isNaN(processedValue) && processedValue !== '') {
                processedValue = parseFloat(processedValue);
              }
              break;
            case 'dict':
              if (typeof processedValue === 'string') {
                processedValue = JSON.parse(processedValue);
              }
              break;
            case 'func':
              // 'func' type can be a function or a float in netpyne
              // In case the value is a float we want to convert "1.4" -> 1.4
              if (!isNaN(processedValue) && processedValue !== '') {
                processedValue = parseFloat(processedValue);
              }
              break;
            default:
              break;
          }

          // Don't sync if new value is empty string
          if (processedValue !== '') {
            this.syncValueWithPython(processedValue);
          }

          if (this.props.callback) {
            this.props.callback(processedValue, this.oldValue);
          }
          this.oldValue = undefined;
        }

        this.setState({
          value: processedValue,
          searchText: processedValue,
          checked: processedValue,
        });
        this.forceUpdate();
      }

      triggerUpdate(updateMethod: () => void): void {
        // common strategy when triggering processing of a value change, delay it, every time there is a change we reset
        if (this.updateTimer !== undefined) {
          clearTimeout(this.updateTimer);
        }
        this.updateTimer = setTimeout(updateMethod, 1000);
      }

      // Default handle (mainly textfields and dropdowns)
      handleChange(event: any, index?: any, value?: any): void {
        let targetValue = value;
        if (event != null && event.target?.value !== undefined) {
          targetValue = event.target.value;
        }

        if (this.oldValue === undefined) {
          this.oldValue = (this.state as PythonControlledState).value;
        }

        this.setState({ value: targetValue });

        if (this.props.validate) {
          this.props.validate(targetValue).then((response) => {
            if (response.errorMsg !== (this.state as PythonControlledState).errorMsg) {
              this.setState({ errorMsg: response.errorMsg });
            }
          });
        }

        // For textfields value is retrieved from the event. For dropdown value is retrieved from the value
        this.triggerUpdate(() => this.updatePythonValue(targetValue));
      }

      // Autocomplete handle
      handleUpdateInput(value: any): void {
        this.triggerUpdate(() => this.updatePythonValue(value));
      }

      // Checkbox
      handleUpdateCheckbox(event: any, isInputChecked: boolean): void {
        this.updatePythonValue(isInputChecked);
      }

      render(): React.ReactNode {
        const wrappedComponentProps: any = { ...this.props };
        if (wrappedComponentProps.key === undefined) {
          wrappedComponentProps.key = wrappedComponentProps.model;
        }
        if (wrappedComponentProps.id === undefined) {
          wrappedComponentProps.id = wrappedComponentProps.model ?? '';
        }

        wrappedComponentProps.id = cleanAttributeValue(wrappedComponentProps.id);

        delete wrappedComponentProps.model;
        delete wrappedComponentProps.handleChange;
        delete wrappedComponentProps.modelName;
        delete wrappedComponentProps.dimensionType;
        delete wrappedComponentProps.noStyle;
        delete wrappedComponentProps.validate;
        delete wrappedComponentProps.prePythonSyncProcessing;
        delete wrappedComponentProps.callback;

        if (wrappedComponentProps.realType === 'func' || wrappedComponentProps.realType === 'float') {
          wrappedComponentProps.helperText = (this.state as PythonControlledState).errorMsg;
        }

        if (!getNameFromWrappedComponent(WrappedComponent).includes('ListComponent')) {
          delete wrappedComponentProps.realType;
        }

        const componentName = getNameFromWrappedComponent(WrappedComponent);
        const currentState = this.state as PythonControlledState;

        switch (componentName) {
          case 'AutoComplete':
            wrappedComponentProps.onUpdateInput = this.handleUpdateInput;
            wrappedComponentProps.searchText = currentState.searchText;
            break;
          case 'Checkbox':
            wrappedComponentProps.onChange = this.handleUpdateCheckbox;
            wrappedComponentProps.checked = currentState.checked;
            delete wrappedComponentProps.searchText;
            delete wrappedComponentProps.dataSource;
            delete wrappedComponentProps.floatingLabelText;
            delete wrappedComponentProps.hintText;
            break;
          default:
            wrappedComponentProps.onChange = this.handleChange;
            wrappedComponentProps.value =
              typeof currentState.value === 'object' &&
              currentState.value !== null &&
              !Array.isArray(currentState.value)
                ? JSON.stringify(currentState.value)
                : currentState.value;

            // Fix case with multiple values: need to set an empty list in case the value is undefined
            wrappedComponentProps.value =
              wrappedComponentProps.multiple &&
              wrappedComponentProps.value !== undefined &&
              !wrappedComponentProps.value
                ? []
                : wrappedComponentProps.value;
            delete wrappedComponentProps.searchText;
            delete wrappedComponentProps.dataSource;
            break;
        }

        return <WrappedComponent {...wrappedComponentProps} />;
      }
    }

    return PythonControlledControl;
  },

  /**
   * Creates a Python-controlled control component with Python data fetching
   */
  createPythonControlledControlWithPythonDataFetch<P extends object>(
    WrappedComponent: ComponentType<P> | any
  ): any {
    const PythonControlledComponent = this.createPythonControlledComponent(WrappedComponent);

    class PythonControlledControlWithPythonDataFetch extends PythonControlledComponent {
      postHandleChange?: (newValue: any, oldValue: any) => void;

      constructor(props: P & PythonControlledProps) {
        super(props);
        this.state = {
          ...this.state,
          value: [],
          pythonData: [],
        };

        this.handleChange =
          this.props.handleChange === undefined
            ? this.handleChange.bind(this)
            : this.props.handleChange.bind(this);
        this.postHandleChange =
          this.props.postHandleChange !== undefined
            ? this.props.postHandleChange.bind(this)
            : undefined;
        this.callPythonMethod();
      }

      UNSAFE_componentWillReceiveProps(nextProps: P & PythonControlledProps): void {
        this.disconnectFromPython();
        this.id = nextProps.id === undefined ? nextProps.model || '' : nextProps.id;

        if (window.GEPPETTO?.ComponentFactory) {
          window.GEPPETTO.ComponentFactory.addExistingComponent(
            (this.state as PythonControlledState).componentType || '',
            this
          );
        }

        if (nextProps.model) {
          this.connectToPython(
            (this.state as PythonControlledState).componentType || '',
            nextProps.model
          );
        }

        this.callPythonMethod();

        if (super.UNSAFE_componentWillReceiveProps) {
          super.UNSAFE_componentWillReceiveProps(nextProps);
        }
      }

      updatePythonValue(newValue: any): void {
        const oldValue = (this.state as PythonControlledState).value;
        this.setState({
          value: newValue,
          searchText: newValue,
          checked: newValue,
        });

        if (this.syncValueWithPython) {
          this.syncValueWithPython(newValue);
        }

        if (this.postHandleChange !== undefined) {
          this.postHandleChange(newValue, oldValue);
        }

        this.forceUpdate();
      }

      // Default handle (mainly textfields and dropdowns)
      handleChange(event: any, index?: any, value?: any): void {
        let targetValue = value;
        if (event != null && event.target?.value !== undefined) {
          targetValue = event.target.value;
        }
        this.setState({ value: targetValue });
        this.updatePythonValue(targetValue);
      }

      compareArrays(array1: any[], array2: any[]): boolean {
        if (!array1 || !array2) {
          return false;
        }

        if (array1.length !== array2.length) {
          return false;
        }

        for (let i = 0, l = array1.length; i < l; i++) {
          if (Array.isArray(array1[i]) && Array.isArray(array2[i])) {
            if (!this.compareArrays(array1[i], array2[i])) {
              return false;
            }
          } else if (array1[i] !== array2[i]) {
            return false;
          }
        }
        return true;
      }

      callPythonMethod = (): void => {
        const params = this.props?.pythonParams || [];
        if (this.props.method) {
          evalPythonMessage(this.props.method, params).then((response: any) => {
            if (this._isMounted) {
              if (Object.keys(response).length !== 0) {
                this.setState({ pythonData: response });
              } else {
                this.setState({ pythonData: [] });
              }
            }
          });
        }
      };

      componentDidUpdate(prevProps: P & PythonControlledProps, prevState: PythonControlledState): void {
        const currentState = this.state as PythonControlledState;
        if (!this.compareArrays(currentState.value as any[], prevState.value as any[])) {
          if (Array.isArray(currentState.value)) {
            for (const v of currentState.value) {
              if (
                Array.isArray(currentState.pythonData) &&
                currentState.pythonData.indexOf(v) < 0
              ) {
                const newValue = [v];
                this.setState({
                  pythonData: (currentState.pythonData || []).concat(newValue),
                });
              }
            }
          }
        }

        if (super.componentDidUpdate) {
          super.componentDidUpdate(prevProps, prevState);
        }
      }

      shouldComponentUpdate(nextProps: P & PythonControlledProps, nextState: PythonControlledState): boolean {
        const currentState = this.state as PythonControlledState;
        return (
          !this.compareArrays(currentState.pythonData as any[], nextState.pythonData as any[]) ||
          !this.compareArrays(currentState.value as any[], nextState.value as any[])
        );
      }

      render(): React.ReactNode {
        const wrappedComponentProps: any = { ...this.props };
        if (wrappedComponentProps.key === undefined) {
          wrappedComponentProps.key = wrappedComponentProps.model;
        }
        if (wrappedComponentProps.id === undefined) {
          wrappedComponentProps.id = wrappedComponentProps.model ?? '';
        }

        wrappedComponentProps.id = cleanAttributeValue(wrappedComponentProps.id);
        wrappedComponentProps.onChange = this.handleChange;
        wrappedComponentProps.value =
          wrappedComponentProps.multiple &&
          (this.state as PythonControlledState).value !== undefined &&
          !(this.state as PythonControlledState).value
            ? []
            : (this.state as PythonControlledState).value;

        delete wrappedComponentProps.model;
        delete wrappedComponentProps.postProcessItems;
        delete wrappedComponentProps.validate;
        delete wrappedComponentProps.prePythonSyncProcessing;
        delete wrappedComponentProps.updates;

        let items: React.ReactNode = null;
        if (this.props.postProcessItems) {
          items = this.props.postProcessItems(
            (this.state as PythonControlledState).pythonData || [],
            wrappedComponentProps.value
          );
        }

        return <WrappedComponent {...wrappedComponentProps}>{items}</WrappedComponent>;
      }
    }

    return PythonControlledControlWithPythonDataFetch;
  },
};

export default PythonControlledCapability;

